#!/usr/bin/env ruby

# An implementation of the Facebook[http://www.facebook.com] API as
# documented at http://developers.facebook.com/documentation.php
#
# See Facebook::Session for details.
#
# Author:: Alpha Chen
# Date:: 2006.08.18

require "digest/md5"
require "hpricot"
require "logger"
require "net/https"

require "mechanize"

module Facebook
  API_SERVER  = "api.facebook.com"
  API_PATH    = "/restserver.php"
  LOGIN_PATH  = "/login.php"
  REQUESTS = %w[
    events.getInWindow
    friends.getTyped
    friends.get
    friends.areFriends
    messages.getCount
    photos.getAlbums
    photos.getCommentCount
    photos.getFromAlbum
    photos.getOfUser
    pokes.getCount
    users.getInfo
    wall.getCount
  ]
  # generate request categories dynamically
  REQUEST_CATEGORIES = REQUESTS.map {|r| r.sub(/\..*$/, '') }.uniq

  # A Ruby implementation of the {Facebook API}[http://developers.facebook.com/documentation.php].
  # This implements the desktop application API, but can also be used for web
  # applications with minor changes. (See the Web Applications section for
  # differences between using Facebook::Session for desktop and web
  # applications.)
  #
  # = Authentication
  #
  # After initializing Facebook::Session, desktop applications have to call
  # create_token to create an authentication token for the login procedure.
  # The authentication token expires after a short period of time, so if this
  # occurs, create_token must be called again to create another authentication
  # token.
  #
  # To authenticate with Facebook[http://www.facebook.com/], the user must
  # visit the login page provided by _login_url_. (Note that it is against
  # Facebook[http://www.facebook.com/]'s TOS to automate this login process;
  # the user must go directly to the site to authenticate!)
  #
  # After the user logs in, get_session must be called to finish the
  # authentication process. Now that the session is established, request
  # calls can be made to the {Facebook API}[http://developers.facebook.com/documentation.php].
  #
  #   f = Facebook::Session.new(api_key, api_secret)
  #   # the user should now visit f.login_url
  #   f.get_session
  #
  # = Example Usage
  #
  # There are several ways to make requests to the
  # {Facebook API}[http://developers.facebook.com/documentation.php].
  # All of the following requests to +method.getCount+ are equivalent.
  #
  #   f.messages.get_count
  #   f.messages.getCount
  #   f.messages_get_count
  #   f.messages_getCount
  #   f.session_request("messages.getCount")
  #
  # To pass parameters to the request, use a hash. The keys can be either
  # symbols or strings.
  #
  #   f.events.get_in_window( { :start_time => (Time.now - 60*60*24*7).to_i,
  #                             :end_time   => (Time.now + 60*60*24*7).to_i } )
  #
  # The result from a request is automatically converted from XML to a Hash,
  # with lists in the result also converted to Arrays.
  # 
  #   > f.message.get_count
  #   => {"total"=>"1", "unread"=>"0"}
  #
  #   > f.events.get_in_window( { :start_time => (Time.now - 60*60*24*7).to_i,
  #                               :end_time   => (Time.now + 60*60*24*7).to_i } )
  #   =>[{"name"=>"Example Event 1","attending"=>"Attending","end_time"=>"1156042800","start_time"=>"1156028400"},
  #      {"name"=>"Example Event 2","attending"=>"Attending","end_time"=>"1156028400","start_time"=>"1156032000"} ]
  #
  # All valid requests are stored in Facebook::REQUESTS.
  #
  # = Web Applications
  #
  # The only difference when using this library for web applications is in
  # authentication. Web applications do not have to create a token, so
  # users can be directly sent to the URL at web_login_url. After the user
  # logs in, he or she is forwarded to a location determined by <tt>callback
  # url + next</tt>, where +callback url+ is specified when creating the web
  # application API key and +next+ is an optional argument to web_login. The
  # authentication token will be passed as a GET parameter +auth_token+ to
  # this address, and can then be set in the Facebook::Session instance.
  #
  #   f = Facebook::Session.new(api_key, api_secret)
  #   # the user should now visit f.web_login_url(path_to_next_url)
  #   f.auth_token = params["auth_token"]
  #
  # = Notes
  #
  # * Web application authentication is UNTESTED.
  #
  class Session
    attr_reader :login_url, :uid
    attr_accessor :log, :auth_token

    # Create an instance of Facebook::Session with the corresponding API key
    # and API secret.
    def initialize(api_key, api_secret)
      # set up logging
      @log = Logger.new(STDOUT)
      @log.level = Logger::WARN

      @api_key        = api_key
      @api_secret     = api_secret
      @session_secret = nil
      @login_url = "http://#{API_SERVER}#{LOGIN_PATH}?api_key=#{@api_key}"
    end
    
    # Provide the login URL for web application authentication. Takes an
    # optional parameter _next_ to forward users to a specific path in their
    # application. (See the authentication documentation for details.)
    def web_login_url(next_url=nil)
      # escape next_url?
      @login_url << "&next=#{next_url}" if next_url
      @login_url
    end

    # Requests an authentication token from the
    # {Facebook API}[http://developers.facebook.com/documentation.php].
    # For desktop application use only. (See the authentication documentation
    # for details.)
    def create_token
      @auth_token = base_request("auth.createToken")
      @login_url << "&auth_token=#{@auth_token}"

      @log.info "login_url: #{@login_url}"
    end

    # Uses WWW::Mechanize to automate logging in to Facebook[http://www.facebook.com]
    # as a desktop application.
    #
    # Note: this is almost certainly against Facebook[http://www.facebook.com]'s
    # TOS, but it's too useful in testing to not have this method.
    def login(email, pass) # :nodoc:
      create_token
      
      agent = WWW::Mechanize.new {|a| a.log = Logger.new(STDOUT); a.log.level = Logger::INFO }
      page = agent.get(@login_url)
      form = page.forms[0]
      form.fields.find {|f| f.name == "email" }.value = email
      form.fields.find {|f| f.name == "pass" }.value = pass
      page = agent.submit(form, form.buttons.first)
      # TODO: Handle tos.php?
      page

      get_session
    end

    # Gets the _session_key_ from Facebook[http://www.facebook.com] to
    # establish the session. If this call originates from a desktop
    # application, a _session_secret_ will also be returned for generating
    # signatures specifically for the session.
    #
    # Note: this connects to the {Facebook API}[http://developers.facebook.com/documentation.php]
    # through HTTPS. Connecting through HTTPS is undefined for web
    # applications, but this should work. If this becomes a problem, the fix
    # is simple; add a parameter to flag whether SSL should be used or not.
    def get_session
      result = base_request("auth.getSession", {:auth_token => @auth_token}, true)
      @session_key    = result["session_key"]
      @uid            = result["uid"]
      @session_secret = result["secret"]

      @log.info "session_key: #{@session_key}"
      @log.info "uid: #{@uid}"
      @log.info "session_secret: #{@session_secret}"
    end

    # Make request calls nicer and more usable. Instead of
    # +f.session_request("events.getInWindow")+, we can use the shortcuts
    # below:
    #
    #   f.events_getInWindow,
    #   f.events_get_in_window,
    #   f.events.getInWindow,
    #   f.events.get_in_window
    def method_missing(name, *args)
      method = name.to_s.sub("_", ".").gsub(/_(.)/) { $1.upcase }
      if REQUESTS.include?(method)
        session_request(method, *args)
      elsif REQUEST_CATEGORIES.include?(method)
        MetaSession.new(method, self)
      else
        super(name, *args)
      end
    end

    # The base method by which request calls are made to the
    # {Facebook API}[http://developers.facebook.com/documentation.php].
    def session_request(method, params={})
      params[:call_id]     = Time.now.to_f.to_s
      params[:session_key] = @session_key

      base_request(method, params)
    end

    private

    # Create a Hash from the XML response. If the hash only has one element,
    # just return that element instead of the entire hash.
    def parse_response(response)
      result = parse_result(response.at("result"))

      # if there's only one key-value pair, return it
      if result.is_a?(Hash) and result.length == 1
        result.values[0]
      else
        result
      end
    end

    # Does the actual work of making a Hash from the XML response.
    def parse_result(result)
      h = Hash.new { [] }
      result.each_child do |child|
        if child.elem?
          if child.name =~ /_elt$/
            # if a list, place items into an array
            h[result.name] = h[result.name] << parse_result(child)
          else
            h[child.name] = parse_result(child)
          end
        else
          # no further XML elements, return the data
          h = child.to_s
          # convert to Integer if we can
          h = h.to_i if h =~ /^\d*$/
        end
      end
      h
    end

    def base_request(method, params={}, use_ssl=false)
      params[:method]  = "facebook.#{method}"
      params[:api_key] = @api_key

      # convert arrays to comma separated strings
      params.each {|k,v| params[k] = v.join(",") if v.is_a?(Array) }
      
      # create signature for request
      params[:sig]     = signature(params)

      @log.debug params

      # use https if we're getting the session
      port = (use_ssl) ? 443 : 80
      res = Net::HTTP.new(API_SERVER, port)
      res.use_ssl = use_ssl

      # send the request and get the response
      req = Net::HTTP::Post.new(API_PATH)
      req.form_data = params
      response = res.start {|http| http.request(req) }.body

      @log.debug req.body
      @log.debug response

      # remove extraneous whitespace
      # NOTE: this could really bite me in the ass later...
      response.gsub!(/>\s*</, "><")

      response = Hpricot(response)

      # handle any errors
      if response.at("fb_error")
        code = response.at("code").inner_html
        msg = response.at("msg").inner_html
        error_message = "#{method} returned error code #{code} (#{msg})"
        @log.error error_message
        @log.error "your_request: #{response.at("your_request")}"
        raise ErrorCode, error_message
      end

      parse_response(response)
    end

    # Calculate MD5 signature of parameters
    def signature(params)
      # use @api_secret if we're doing authentication
      secret = (params[:method] =~ /^facebook\.auth\./) ? @api_secret : @session_secret
      Digest::MD5.hexdigest(params.sort {|a,b| a.to_s <=> b.to_s }.map {|i| "#{i[0]}=#{i[1]}" }.join + secret)
    end
  end

  # enables f.events.get_in_window, f.events.getInWindow
  class MetaSession
    def initialize(request_category, facebook_session)
      @request_category = request_category
      @facebook_session = facebook_session
    end
    
    def method_missing(method, *args)
      full_request = "#{@request_category}.#{method.to_s.gsub(/_(.)/) { $1.upcase }}"
      if REQUESTS.include?(full_request)
        @facebook_session.session_request(full_request, *args)
      else
        super(method, *args)
      end
    end
  end

  class ErrorCode < RuntimeError; end
end

